Fix duplicate entity groups in Swagger UI when entities have descriptions#3099
Fix duplicate entity groups in Swagger UI when entities have descriptions#3099
Conversation
|
@copilot There is a related problem in #2946. Please include in this PR. Current behavior: The OpenAPI document shows all HTTP methods (GET, POST, PUT, PATCH, DELETE) for every entity, regardless of:
Example scenario: {
"entities": {
"Book": {
"source": { "object": "books", "type": "table" },
"permissions": [
{
"role": "anonymous",
"actions": [
{ "action": "read" } // ← Only READ allowed
]
}
]
}
}
}Current OpenAPI output (WRONG): paths:
/api/Book:
get: # ✅ Should show (read allowed)
post: # ❌ Shouldn't show (create not allowed)
/api/Book/{id}:
get: # ✅ Should show (read allowed)
put: # ❌ Shouldn't show (update not allowed)
patch: # ❌ Shouldn't show (update not allowed)
delete: # ❌ Shouldn't show (delete not allowed)Expected OpenAPI output (for anonymous role): paths:
/api/Book:
get: # ✅ Only show allowed methods
/api/Book/{id}:
get: # ✅ Only show allowed methodsWhy This Matters1. API Documentation AccuracyCurrent OpenAPI docs mislead developers by showing methods they can't use:
2. Security Through ObscurityWhile not a primary security measure, hiding unavailable methods:
3. Better Developer Experience
4. Role-Specific DocumentationDifferent users see different API capabilities:
Current ImplementationThe OpenAPI generator currently creates all methods regardless of permissions: private static Dictionary<OperationType, bool> GetConfiguredRestOperations(Entity entity, DatabaseObject dbObject)
{
Dictionary<OperationType, bool> configuredOperations = new()
{
[OperationType.Get] = false,
[OperationType.Post] = false,
[OperationType.Put] = false,
[OperationType.Patch] = false,
[OperationType.Delete] = false
};
if (dbObject.SourceType == EntitySourceType.StoredProcedure && entity is not null)
{
// For stored procedures, check configured HTTP methods
// ...
}
else
{
// For tables/views, ALL operations are enabled by default
configuredOperations[OperationType.Get] = true;
configuredOperations[OperationType.Post] = true;
configuredOperations[OperationType.Put] = true;
configuredOperations[OperationType.Patch] = true;
configuredOperations[OperationType.Delete] = true; // ← No permission check!
}
return configuredOperations;
}The problem: This method doesn't check entity permissions—it assumes all CRUD operations are available. Proposed SolutionThe enhancement suggests: 1. Detect Current User's RoleWhen generating OpenAPI, determine the requesting user's role from:
2. Filter Operations Based on PermissionsFor each entity, check the role's permissions and only include allowed operations: // Pseudo-code for proposed solution
private Dictionary<OperationType, bool> GetConfiguredRestOperations(
Entity entity,
DatabaseObject dbObject,
string currentRole) // ← NEW: role parameter
{
var configuredOperations = new Dictionary<OperationType, bool>();
// Get permissions for the current role
var rolePermissions = entity.Permissions
.FirstOrDefault(p => p.Role == currentRole);
if (rolePermissions == null)
{
// Role has no permissions, show nothing
return configuredOperations;
}
// Map actions to HTTP methods
foreach (var action in rolePermissions.Actions)
{
switch (action.Action)
{
case "read":
configuredOperations[OperationType.Get] = true;
break;
case "create":
configuredOperations[OperationType.Post] = true;
break;
case "update":
configuredOperations[OperationType.Put] = true;
configuredOperations[OperationType.Patch] = true;
break;
case "delete":
configuredOperations[OperationType.Delete] = true;
break;
case "*":
// All operations
configuredOperations[OperationType.Get] = true;
configuredOperations[OperationType.Post] = true;
configuredOperations[OperationType.Put] = true;
configuredOperations[OperationType.Patch] = true;
configuredOperations[OperationType.Delete] = true;
break;
}
}
return configuredOperations;
}3. Generate Role-Specific OpenAPI DocumentsOption A: Dynamic per-request
Option B: Multiple static documents
Option C: Security schemes with role filter
Example ImplementationConfiguration:{
"entities": {
"Book": {
"source": { "object": "books", "type": "table" },
"permissions": [
{
"role": "anonymous",
"actions": ["read"]
},
{
"role": "authenticated",
"actions": ["read", "create"]
},
{
"role": "admin",
"actions": ["*"]
}
]
}
}
}OpenAPI for
|
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
I need clarification on the scope for issue #2946: Question 1: Role-specific vs. All-roles documentation?
Question 2: Anonymous role handling?
Current behavior: OpenAPI shows all CRUD operations regardless of ANY role's permissions, which is misleading. Suggested approach (Option A): Filter to show only operations that at least one role can perform. This is the minimal change that fixes the core issue without requiring role-specific documentation infrastructure. Please confirm which approach you prefer so I can implement the right solution. |
Tag name computation differs between CreateDocument and BuildPaths. In CreateDocument (line 148): string restPath = entity.Rest?.Path ?? kvp.Key;In BuildPaths (line 213, via GetEntityRestPath): string entityRestPath = GetEntityRestPath(entities[entityName].Rest, entityName);
// which does: entityRestSettings.Path.TrimStart('/')If a user configures "path": "/Actor" (with leading slash), then CreateDocument stores the tag key as "/Actor" (including the slash) but BuildPaths looks up "Actor" (slash trimmed via GetEntityRestPath). The TryGetValue on line 241 will miss, fall into the else-branch, and create a new tag instance, which is exactly the duplication you are trying to fix. Fix: use GetEntityRestPath in CreateDocument too, so both paths compute the same key: string restPath = GetEntityRestPath(entity.Rest, kvp.Key);This also matches the existing pattern in BuildPaths and keeps behavior consistent.
if (!globalTagsDict.ContainsKey(restPath))
{
globalTagsDict[restPath] = new OpenApiTag { ... };
}ContainsKey + indexer is fine, but TryAdd would be a one-liner: globalTagsDict.TryAdd(restPath, new OpenApiTag { ... });Small thing, just cleaner.
else
{
// Fallback: create a new tag if not found in global tags (should not happen in normal flow)
tags.Add(new OpenApiTag
{
Name = entityRestPath,
Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description
});
}If the fix in comment #1 is applied (using GetEntityRestPath consistently), this fallback should truly never fire. Consider logging a warning here instead of silently creating a new tag. A silent fallback that creates a separate tag instance is exactly the bug this PR is fixing, so it would silently regress if the key mismatch reappears. Something like: _logger.LogWarning("Tag for REST path '{EntityRestPath}' not found in global tags dictionary.", entityRestPath);
if (!globalTagsDict.ContainsKey(restPath))When two entities share the same REST path, only the first entity's description is kept. The second entity's description is silently dropped. This is probably fine for now since duplicate REST paths are unusual, but worth documenting with a comment like:
The OpenApiDocumentor_NoDuplicateTags test is in StoredProcedureGeneration.cs, but it tests general tag behavior, not stored procedure behavior. It would fit better in a dedicated test file (like TagValidationTests.cs) or at least in a more general test class. The existing document from StoredProcedureGeneration only has one entity (sp1), so the test only proves no duplicates with a single entity. The real bug requires multiple entities, at least one with a description. Consider adding a test that creates 2+ entities with descriptions to actually exercise the fix.
var tagNames = tags.Select(t => t.Name).ToList();
var distinctTagNames = tagNames.Distinct().ToList();Using var with LINQ projections is fine in practice, but the existing test file consistently uses explicit types (e.g., IList, Dictionary<OperationType, bool>). Match the file's style for consistency: List<string> tagNames = tags.Select(t => t.Name).ToList();
List<string> distinctTagNames = tagNames.Distinct().ToList();
bool foundMatchingTag = tags.Any(globalTag => ReferenceEquals(globalTag, operationTag));Good: testing reference identity proves the tags are shared instances. But note that this test only exercises the single-entity case (the sp1 entity). With only one entity, there's no opportunity for the actual duplicate-tag bug to manifest. The strongest test would configure 2+ entities (one table with a description, one without) and verify no duplicates and shared instances.
Jerry's PR comment asked to also include issue #2946 (role-based operation filtering). The Copilot author replied asking for clarification. This PR should stay focused on the duplicate tag fix (#2968). The role-based filtering is a separate feature with different scope. Keep them separate. |
…uality Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
All feedback addressed in commit 46f6a5b:
The critical fix prevents the exact bug scenario you identified: "/Actor" (with slash) stored in CreateDocument but "Actor" (trimmed) looked up in BuildPaths would cause the fallback to fire, creating duplicate tag instances. |
| List<OpenApiTag> globalTags = new(); | ||
| // Store tags in a dictionary to ensure we can reuse the same tag instances in BuildPaths | ||
| Dictionary<string, OpenApiTag> globalTagsDict = new(); | ||
| foreach (KeyValuePair<string, Entity> kvp in runtimeConfig.Entities) |
There was a problem hiding this comment.
might be unrelated to the exact PR but since it touches this part- it seems the loop iterates over all runtimeConfig.Entities without checking whether entity.Rest.Enabled is true. This means entities with REST disabled will still produce a global tag in the OpenAPI document. BuildPaths below skips disabled entities, so we end up with the tags that have no operations?
There was a problem hiding this comment.
Fixed in 0044782. The BuildOpenApiDocument method in main already included the !entity.Rest.Enabled || !HasAnyAvailableOperations(entity, role) check (line 210), which filters out REST-disabled entities before creating global tags. After merging main, this is now properly in place — REST-disabled entities produce no global tags, matching the skip behavior in BuildPaths.
| { | ||
| openApiTag | ||
| }; | ||
| // Fallback: create a new tag if not found in global tags. |
There was a problem hiding this comment.
The fallback else block creates a new tag instance that won't be in the global tags list which is exactly the original bug. If this fallback ever triggers, the duplicate tag problem returns silently. The comments say "this should not happen," but defensive code that reintroduces the bug it's fixing is concerning.
Recommendation: Either:
- Throw an exception / log a warning so the bug is surfaced immediately or
- Add the newly created tag back into the globalTags dictionary so it at least doesn't cause duplicates if called for the same path again.
There was a problem hiding this comment.
Fixed in 0044782. The fallback else block is removed entirely. Now if the tag isn't found in the global dictionary, we log a warning via _logger.LogWarning(...) and continue to skip the entity — no duplicate tag instance is ever created. The warning message explicitly states this indicates a key mismatch between BuildOpenApiDocument and BuildPaths, making any regression immediately visible in logs.
|
@copilot please merge latest main branch and resolve the conflicts. after that address the PR review comments and implement fix if needed in the same branch- copilot/fix-duplicate-entity-groups. ensure there is test coverage and the changes are not deviated from the requirements and details in this PR's description and comments. once done, reply back to each of the open review comments. |
…, use GetEntityRestPath consistently, log warning on key mismatch Co-authored-by: souvikghosh04 <210500244+souvikghosh04@users.noreply.github.com>
Done in 0044782. Merged latest main (resolved conflict in
|
…onflict in OpenApiDocumentor.cs
There was a problem hiding this comment.
Pull request overview
Fixes Swagger UI duplicate entity groups by ensuring OpenAPI Tag objects are deduplicated and reused between the document-level Tags collection and operation-level Tags, with consistent REST path normalization (leading slash trimming).
Changes:
- Build global tags as a dictionary keyed by normalized REST path and reuse the same
OpenApiTaginstances inBuildPaths. - Normalize REST paths consistently via
GetEntityRestPath(...)to avoid tag key mismatches when config paths include leading slashes. - Add a test asserting no duplicate tags and that operations reference the same tag instances as the global tags list.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/Core/Services/OpenAPI/OpenApiDocumentor.cs | Deduplicates and reuses OpenApiTag instances across global and operation tags; normalizes REST paths consistently. |
| src/Service.Tests/OpenApiDocumentor/StoredProcedureGeneration.cs | Adds a test to validate tag deduplication and tag instance reuse across operations. |
Comments suppressed due to low confidence (1)
src/Core/Services/OpenAPI/OpenApiDocumentor.cs:224
globalTagsDict.TryAdd(restPath, ...)means whichever entity is enumerated first “wins” the tag description when multiple entities share the same normalized REST path. Since entity enumeration order isn’t guaranteed across all inputs, the selected description can be nondeterministic and could silently drop a non-empty description. Consider: if the key already exists and the existing description is null/empty, update it with the new non-empty description; and if both are non-empty but differ, log a warning (or fail validation) so the conflict is explicit.
string restPath = GetEntityRestPath(entity.Rest, kvp.Key);
// First entity's description wins when multiple entities share the same REST path.
globalTagsDict.TryAdd(restPath, new OpenApiTag
{
Name = restPath,
Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description
});
| // Reuse the existing tag from the global tags dictionary instead of creating a new instance. | ||
| // This ensures Swagger UI displays only one group per entity by using the same object reference. | ||
| if (!globalTags.TryGetValue(entityRestPath, out OpenApiTag? existingTag)) | ||
| { | ||
| Name = entityRestPath, | ||
| Description = string.IsNullOrWhiteSpace(entity.Description) ? null : entity.Description | ||
| }; | ||
| _logger.LogWarning("Tag for REST path '{EntityRestPath}' not found in global tags dictionary. This indicates a key mismatch between BuildOpenApiDocument and BuildPaths.", entityRestPath); | ||
| continue; | ||
| } |
There was a problem hiding this comment.
BuildPaths logs a warning and continues when the tag isn’t found in globalTags. This can happen in normal, non-mismatch scenarios for role-filtered documents: BuildOpenApiDocument only adds tags when HasAnyAvailableOperations(entity, role) is true, but BuildPaths iterates all REST-enabled entities from metadata and attempts the tag lookup before checking configuredRestOperations. This can produce noisy warnings and also couples path generation to tag presence. Consider moving the permission/operation filtering (e.g., HasAnyAvailableOperations or configuredRestOperations.ContainsValue(true)) before the tag lookup, and only warn/throw when an entity should be included but the tag key is still missing.
| public void OpenApiDocumentor_NoDuplicateTags() | ||
| { | ||
| // Act: Get the tags from the OpenAPI document | ||
| IList<OpenApiTag> tags = _openApiDocument.Tags; | ||
|
|
||
| // Get all tag names | ||
| List<string> tagNames = tags.Select(t => t.Name).ToList(); | ||
|
|
||
| // Get distinct tag names | ||
| List<string> distinctTagNames = tagNames.Distinct().ToList(); | ||
|
|
||
| // Assert: The number of tags should equal the number of distinct tag names (no duplicates) | ||
| Assert.AreEqual(distinctTagNames.Count, tagNames.Count, | ||
| $"Duplicate tags found in OpenAPI document. Tags: {string.Join(", ", tagNames)}"); |
There was a problem hiding this comment.
This test verifies reference equality between global and operation tags, but it doesn’t exercise the reported key-mismatch scenario where an entity has a REST path with a leading slash (e.g., "/Actor"). As written, _runtimeEntities uses the default REST path, so the test would pass even if slash-normalization regressed. Consider setting the test entity’s Rest.Path to a value with a leading slash (e.g., "/sp1") and asserting that both the global tag name and the operation tag name are normalized (no leading slash) and reference the same instance.
Why make this change?
Swagger UI displays duplicate groups for entities with descriptions (e.g., "Actor" appears twice). OpenAPI tags were instantiated separately in
BuildOpenApiDocument(global tags) andBuildPaths(operation tags), causing Swagger UI to treat identical tag names as distinct groups.Additionally, a critical bug existed where REST paths with leading slashes (e.g.,
"path": "/Actor") would cause key mismatches betweenBuildOpenApiDocumentandBuildPaths, leading to duplicate tag instances even with the shared dictionary approach.What is this change?
Share tag instances between global and operation-level tags, with consistent REST path normalization. Applied on top of main's refactored
BuildOpenApiDocument/BuildPathsarchitecture:Before:
After:
Changes:
OpenApiDocumentor.BuildOpenApiDocument:Dictionary<string, OpenApiTag>keyed by REST pathGetEntityRestPathfor consistent path normalization (trims leading slashes)TryAddfor cleaner deduplicationBuildPathsfor instance reuseOpenApiDocumentor.BuildPaths:globalTagsdictionary parameter and reuse existing tag instanceselseblock (which would reintroduce duplicate tags) with_logger.LogWarning+continueto surface any key mismatch immediatelyStoredProcedureGeneration.cs:OpenApiDocumentor_NoDuplicateTagstestvarfor consistency with test file conventionsHow was this tested?
OpenApiDocumentor_NoDuplicateTagstest verifying:ReferenceEquals)Sample Request(s)
N/A - This is an OpenAPI document generation fix. The Swagger UI at
/api/openapiwill now display each entity group once instead of duplicating them, even when REST paths are configured with leading slashes.Original prompt
This section details on the original issue you should resolve
<issue_title>[Bug]: Swagger UI shows duplicate entity groups for single entity definition</issue_title>
<issue_description>When using Data API builder 1.7.75, the Swagger UI generated at
/api/openapidisplays theActorentity twice, even thoughActoris defined only once in the configuration. No other entity shares its REST path or alias.Steps to Reproduce:
Use this configuration (trimmed for brevity):
{ "entities": { "Actor": { "source": { "object": "dbo.Actor", "type": "table" }, "rest": { "enabled": true, "path": "/Actor" } }, "Character": { "source": { "object": "dbo.Character", "type": "table" }, "rest": { "enabled": true, "path": "/Character" } }, "SeriesActors": { "source": { "object": "dbo.SeriesActors", "type": "view" }, "rest": { "enabled": true, "path": "/SeriesActors" } }, "GetSeriesActors": { "source": { "object": "dbo.GetSeriesActors", "type": "stored-procedure" }, "rest": { "enabled": true, "path": "/GetSeriesActors", "methods": ["post"] } } } }Start DAB (
dab start --config dab-config.json).Open the Swagger UI at
/api/openapi.Expected Behavior:
Each entity should appear once under its respective REST path group.
Actual Behavior:
Swagger shows two separate groups labeled
Actor, each listing the same endpoints (GET,PUT,PATCH,DELETE,POST).Environment:
Notes:
path: "/Actor".OpenApiDocumentBuilder.</issue_description>
<agent_instructions>Resolve this with as little new code as possible</agent_instructions>
Comments on the Issue (you are @copilot in this section)
@JerryNixon ### The Root CauseThe issue is that tags are being added to the OpenAPI document in TWO different places, and when an entity has a
descriptionproperty, it creates tags with different object identities but the same name.Where Tags Are Created
1. Global Tags (in
CreateDocumentmethod):2. Per-Path Tags (in
BuildPathsmethod):These per-path tags are then assigned to each operation, and each operation is added to the document's paths.
Why This Causes Duplication
When Swagger UI renders the OpenAPI document, it looks at:
doc.Tags) - Added inCreateDocumentTagsproperty) - Added inBuildPathsEven though both have the same
Name("Actor"), they are different object instances with the sameDescription. Swagger UI treats them as distinct tag definitions and displays them separately.Why You Noticed It with Descriptions
The user comment "I think this has to do with
descriptions" is correct! Here's why:Description = null, so Swagger might merge them💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.